// Copyright 2013 The Flutter Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async';
import 'dart:convert';
import 'dart:io' as io;

import 'package:args/command_runner.dart';
import 'package:file/file.dart';
import 'package:flutter_plugin_tools/src/common/core.dart';
import 'package:flutter_plugin_tools/src/publish_command.dart';
import 'package:git/git.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:mockito/mockito.dart';
import 'package:test/test.dart';

import 'mocks.dart';
import 'util.dart';

void main() {
  late MockPlatform platform;
  late Directory packagesDir;
  late TestProcessRunner processRunner;
  late PublishCommand command;
  late CommandRunner<void> commandRunner;
  late MockStdin mockStdin;
  // Map of package name to mock response.
  late Map<String, Map<String, dynamic>> mockHttpResponses;

  void createMockCredentialFile() {
    packagesDir.fileSystem.file(command.credentialsPath)
      ..createSync(recursive: true)
      ..writeAsStringSync('some credential');
  }

  setUp(() async {
    platform = MockPlatform(isLinux: true);
    processRunner = TestProcessRunner();
    final GitDir gitDir;
    (
      :packagesDir,
      processRunner: _,
      gitProcessRunner: _,
      :gitDir,
    ) = configureBaseCommandMocks(
      platform: platform,
      customProcessRunner: processRunner,
      customGitProcessRunner: processRunner,
    );
    platform.environment['HOME'] = '/home';

    mockHttpResponses = <String, Map<String, dynamic>>{};
    final mockClient = MockClient((http.Request request) async {
      final String packageName = request.url.pathSegments.last.replaceAll(
        '.json',
        '',
      );
      final Map<String, dynamic>? response = mockHttpResponses[packageName];
      if (response != null) {
        return http.Response(json.encode(response), 200);
      }
      // Default to simulating the plugin never having been published.
      return http.Response('', 404);
    });

    mockStdin = MockStdin();
    command = PublishCommand(
      packagesDir,
      platform: platform,
      processRunner: processRunner,
      stdinput: mockStdin,
      gitDir: gitDir,
      httpClient: mockClient,
    );
    commandRunner = CommandRunner<void>('tester', '')..addCommand(command);
  });

  group('Initial validation', () {
    test('refuses to proceed with dirty files', () async {
      final RepositoryPackage plugin = createFakePlugin(
        'foo',
        packagesDir,
        examples: <String>[],
      );

      processRunner
          .mockProcessesForExecutable['git-status'] = <FakeProcessInfo>[
        FakeProcessInfo(
          MockProcess(stdout: '?? ${plugin.directory.childFile('tmp').path}\n'),
        ),
      ];

      Error? commandError;
      final List<String> output = await runCapturingPrint(
        commandRunner,
        <String>['publish', '--packages=foo'],
        errorHandler: (Error e) {
          commandError = e;
        },
      );

      expect(commandError, isA<ToolExit>());
      expect(
        output,
        containsAllInOrder(<Matcher>[
          contains(
            "There are files in the package directory that haven't "
            'been saved in git. Refusing to publish these files:\n\n'
            '?? /packages/foo/tmp\n\n'
            'If the directory should be clean, you can run `git clean -xdf && '
            'git reset --hard HEAD` to wipe all local changes.',
          ),
          contains(
            'foo:\n'
            '    uncommitted changes',
          ),
        ]),
      );
    });

    test("fails immediately if the remote doesn't exist", () async {
      createFakePlugin('foo', packagesDir, examples: <String>[]);

      processRunner.mockProcessesForExecutable['git-remote'] =
          <FakeProcessInfo>[FakeProcessInfo(MockProcess(exitCode: 1))];

      Error? commandError;
      final List<String> output = await runCapturingPrint(
        commandRunner,
        <String>['publish', '--packages=foo'],
        errorHandler: (Error e) {
          commandError = e;
        },
      );

      expect(commandError, isA<ToolExit>());
      expect(
        output,
        containsAllInOrder(<Matcher>[
          contains('Unable to find URL for remote upstream; cannot push tags'),
        ]),
      );
    });
  });

  group('pre-publish script', () {
    test('runs if present', () async {
      final RepositoryPackage package = createFakePackage(
        'foo',
        packagesDir,
        examples: <String>[],
      );
      package.prePublishScript.createSync(recursive: true);

      final List<String> output = await runCapturingPrint(
        commandRunner,
        <String>['publish', '--packages=foo'],
      );

      expect(
        output,
        containsAllInOrder(<Matcher>[
          contains('Running pre-publish hook tool/pre_publish.dart...'),
        ]),
      );
      expect(
        processRunner.recordedCalls,
        containsAllInOrder(<ProcessCall>[
          ProcessCall('dart', const <String>[
            'pub',
            'get',
          ], package.directory.path),
          ProcessCall('dart', const <String>[
            'run',
            'tool/pre_publish.dart',
          ], package.directory.path),
        ]),
      );
    });

    test('causes command failure if it fails', () async {
      final RepositoryPackage package = createFakePackage(
        'foo',
        packagesDir,
        isFlutter: true,
        examples: <String>[],
      );
      package.prePublishScript.createSync(recursive: true);

      processRunner.mockProcessesForExecutable['dart'] = <FakeProcessInfo>[
        FakeProcessInfo(MockProcess(exitCode: 1), <String>[
          'run',
        ]), // run tool/pre_publish.dart
      ];

      Error? commandError;
      final List<String> output = await runCapturingPrint(
        commandRunner,
        <String>['publish', '--packages=foo'],
        errorHandler: (Error e) {
          commandError = e;
        },
      );

      expect(commandError, isA<ToolExit>());
      expect(
        output,
        containsAllInOrder(<Matcher>[contains('Pre-publish script failed.')]),
      );
      expect(
        processRunner.recordedCalls,
        containsAllInOrder(<ProcessCall>[
          ProcessCall(getFlutterCommand(platform), const <String>[
            'pub',
            'get',
          ], package.directory.path),
          ProcessCall('dart', const <String>[
            'run',
            'tool/pre_publish.dart',
          ], package.directory.path),
        ]),
      );
    });
  });

  group('Publishes package', () {
    test('while showing all output from pub publish to the user', () async {
      createFakePlugin('plugin1', packagesDir, examples: <String>[]);
      createFakePlugin('plugin2', packagesDir, examples: <String>[]);

      processRunner.mockProcessesForExecutable['flutter'] = <FakeProcessInfo>[
        FakeProcessInfo(
          MockProcess(
            stdout: 'Foo',
            stderr: 'Bar',
            stdoutEncoding: utf8,
            stderrEncoding: utf8,
          ),
          <String>['pub', 'publish'],
        ), // publish for plugin1
        FakeProcessInfo(
          MockProcess(
            stdout: 'Baz',
            stdoutEncoding: utf8,
            stderrEncoding: utf8,
          ),
          <String>['pub', 'publish'],
        ), // publish for plugin2
      ];

      final List<String> output = await runCapturingPrint(
        commandRunner,
        <String>['publish', '--packages=plugin1,plugin2'],
      );

      expect(
        output,
        containsAllInOrder(<Matcher>[
          contains('Running `pub publish ` in /packages/plugin1...'),
          contains('Foo'),
          contains('Bar'),
          contains('Package published!'),
          contains('Running `pub publish ` in /packages/plugin2...'),
          contains('Baz'),
          contains('Package published!'),
        ]),
      );
    });

    test('forwards input from the user to `pub publish`', () async {
      createFakePlugin('foo', packagesDir, examples: <String>[]);

      mockStdin.mockUserInputs.add(utf8.encode('user input'));

      await runCapturingPrint(commandRunner, <String>[
        'publish',
        '--packages=foo',
      ]);

      expect(
        processRunner.mockPublishProcess.stdinMock.lines,
        contains('user input'),
      );
    });

    test('forwards --pub-publish-flags to pub publish', () async {
      final RepositoryPackage plugin = createFakePlugin(
        'foo',
        packagesDir,
        examples: <String>[],
      );

      await runCapturingPrint(commandRunner, <String>[
        'publish',
        '--packages=foo',
        '--pub-publish-flags',
        '--dry-run,--server=bar',
      ]);

      expect(
        processRunner.recordedCalls,
        contains(
          ProcessCall('flutter', const <String>[
            'pub',
            'publish',
            '--dry-run',
            '--server=bar',
          ], plugin.path),
        ),
      );
    });

    test(
      '--skip-confirmation flag automatically adds --force to --pub-publish-flags',
      () async {
        createMockCredentialFile();
        final RepositoryPackage plugin = createFakePlugin(
          'foo',
          packagesDir,
          examples: <String>[],
        );

        await runCapturingPrint(commandRunner, <String>[
          'publish',
          '--packages=foo',
          '--skip-confirmation',
          '--pub-publish-flags',
          '--server=bar',
        ]);

        expect(
          processRunner.recordedCalls,
          contains(
            ProcessCall('flutter', const <String>[
              'pub',
              'publish',
              '--server=bar',
              '--force',
            ], plugin.path),
          ),
        );
      },
    );

    test('--force is only added once, regardless of plugin count', () async {
      createMockCredentialFile();
      final RepositoryPackage plugin1 = createFakePlugin(
        'plugin_a',
        packagesDir,
        examples: <String>[],
      );
      final RepositoryPackage plugin2 = createFakePlugin(
        'plugin_b',
        packagesDir,
        examples: <String>[],
      );

      await runCapturingPrint(commandRunner, <String>[
        'publish',
        '--packages=plugin_a,plugin_b',
        '--skip-confirmation',
        '--pub-publish-flags',
        '--server=bar',
      ]);

      expect(
        processRunner.recordedCalls,
        containsAllInOrder(<ProcessCall>[
          ProcessCall('flutter', const <String>[
            'pub',
            'publish',
            '--server=bar',
            '--force',
          ], plugin1.path),
          ProcessCall('flutter', const <String>[
            'pub',
            'publish',
            '--server=bar',
            '--force',
          ], plugin2.path),
        ]),
      );
    });

    test(
      'creates credential file from envirnoment variable if necessary',
      () async {
        createFakePlugin('foo', packagesDir, examples: <String>[]);
        const credentials = 'some credential';
        platform.environment['PUB_CREDENTIALS'] = credentials;

        await runCapturingPrint(commandRunner, <String>[
          'publish',
          '--packages=foo',
          '--skip-confirmation',
          '--pub-publish-flags',
          '--server=bar',
        ]);

        final File credentialFile = packagesDir.fileSystem.file(
          command.credentialsPath,
        );
        expect(credentialFile.existsSync(), true);
        expect(credentialFile.readAsStringSync(), credentials);
      },
    );

    test('throws if pub publish fails', () async {
      createFakePlugin('foo', packagesDir, examples: <String>[]);

      processRunner.mockProcessesForExecutable['flutter'] = <FakeProcessInfo>[
        FakeProcessInfo(MockProcess(exitCode: 128), <String>['pub', 'publish']),
      ];

      Error? commandError;
      final List<String> output = await runCapturingPrint(
        commandRunner,
        <String>['publish', '--packages=foo'],
        errorHandler: (Error e) {
          commandError = e;
        },
      );

      expect(commandError, isA<ToolExit>());
      expect(
        output,
        containsAllInOrder(<Matcher>[contains('Publishing foo failed.')]),
      );
    });

    test('publish, dry run', () async {
      final RepositoryPackage plugin = createFakePlugin(
        'foo',
        packagesDir,
        examples: <String>[],
      );

      final List<String> output = await runCapturingPrint(
        commandRunner,
        <String>['publish', '--packages=foo', '--dry-run'],
      );

      expect(
        processRunner.recordedCalls.map((ProcessCall call) => call.executable),
        isNot(contains('git-push')),
      );
      expect(
        output,
        containsAllInOrder(<Matcher>[
          contains('=============== DRY RUN ==============='),
          contains('Running for foo'),
          contains('Running `pub publish ` in ${plugin.path}...'),
          contains('Tagging release foo-v0.0.1...'),
          contains('Pushing tag to upstream...'),
          contains('Published foo successfully!'),
        ]),
      );
    });

    test('can publish non-flutter package', () async {
      const packageName = 'a_package';
      createFakePackage(packageName, packagesDir);

      final List<String> output = await runCapturingPrint(
        commandRunner,
        <String>['publish', '--packages=$packageName'],
      );

      expect(
        output,
        containsAllInOrder(<Matcher>[
          contains('Running `pub publish ` in /packages/a_package...'),
          contains('Package published!'),
        ]),
      );
    });

    test('skips publish with --tag-for-auto-publish', () async {
      const packageName = 'a_package';
      createFakePackage(packageName, packagesDir);

      final List<String> output = await runCapturingPrint(
        commandRunner,
        <String>[
          'publish',
          '--packages=$packageName',
          '--tag-for-auto-publish',
        ],
      );

      // There should be no variant of any command containing "publish".
      expect(
        processRunner.recordedCalls.map((ProcessCall call) => call.toString()),
        isNot(contains(contains('publish'))),
      );
      // The output should indicate that it was tagged, not published.
      expect(
        output,
        containsAllInOrder(<Matcher>[
          contains('Tagged a_package successfully!'),
        ]),
      );
    });
  });

  group('Tags release', () {
    test('with the version and name from the pubspec.yaml', () async {
      createFakePlugin('foo', packagesDir, examples: <String>[]);
      await runCapturingPrint(commandRunner, <String>[
        'publish',
        '--packages=foo',
      ]);

      expect(
        processRunner.recordedCalls,
        contains(const ProcessCall('git-tag', <String>['foo-v0.0.1'], null)),
      );
    });

    test('only if publishing succeeded', () async {
      createFakePlugin('foo', packagesDir, examples: <String>[]);

      processRunner.mockProcessesForExecutable['flutter'] = <FakeProcessInfo>[
        FakeProcessInfo(MockProcess(exitCode: 128), <String>['pub', 'publish']),
      ];

      Error? commandError;
      final List<String> output = await runCapturingPrint(
        commandRunner,
        <String>['publish', '--packages=foo'],
        errorHandler: (Error e) {
          commandError = e;
        },
      );

      expect(commandError, isA<ToolExit>());
      expect(
        output,
        containsAllInOrder(<Matcher>[contains('Publishing foo failed.')]),
      );
      expect(
        processRunner.recordedCalls,
        isNot(
          contains(const ProcessCall('git-tag', <String>['foo-v0.0.1'], null)),
        ),
      );
    });

    test('when passed --tag-for-auto-publish', () async {
      createFakePlugin('foo', packagesDir, examples: <String>[]);
      await runCapturingPrint(commandRunner, <String>[
        'publish',
        '--packages=foo',
        '--tag-for-auto-publish',
      ]);

      expect(
        processRunner.recordedCalls,
        contains(const ProcessCall('git-tag', <String>['foo-v0.0.1'], null)),
      );
    });
  });

  group('Pushes tags', () {
    test('to upstream by default', () async {
      createFakePlugin('foo', packagesDir, examples: <String>[]);

      mockStdin.readLineOutput = 'y';

      final List<String> output = await runCapturingPrint(
        commandRunner,
        <String>['publish', '--packages=foo'],
      );

      expect(
        processRunner.recordedCalls,
        contains(
          const ProcessCall('git-push', <String>[
            'upstream',
            'foo-v0.0.1',
          ], null),
        ),
      );
      expect(
        output,
        containsAllInOrder(<Matcher>[
          contains('Pushing tag to upstream...'),
          contains('Published foo successfully!'),
        ]),
      );
    });

    test(
      'does not ask for user input if the --skip-confirmation flag is on',
      () async {
        createMockCredentialFile();
        createFakePlugin('foo', packagesDir, examples: <String>[]);

        final List<String> output = await runCapturingPrint(
          commandRunner,
          <String>['publish', '--skip-confirmation', '--packages=foo'],
        );

        expect(
          processRunner.recordedCalls,
          contains(
            const ProcessCall('git-push', <String>[
              'upstream',
              'foo-v0.0.1',
            ], null),
          ),
        );
        expect(
          output,
          containsAllInOrder(<Matcher>[
            contains('Published foo successfully!'),
          ]),
        );
      },
    );

    test('when passed --tag-for-auto-publish', () async {
      createFakePlugin('foo', packagesDir, examples: <String>[]);
      await runCapturingPrint(commandRunner, <String>[
        'publish',
        '--packages=foo',
        '--skip-confirmation',
        '--tag-for-auto-publish',
      ]);

      expect(
        processRunner.recordedCalls,
        contains(
          const ProcessCall('git-push', <String>[
            'upstream',
            'foo-v0.0.1',
          ], null),
        ),
      );
    });

    test('to upstream by default, dry run', () async {
      final RepositoryPackage plugin = createFakePlugin(
        'foo',
        packagesDir,
        examples: <String>[],
      );

      mockStdin.readLineOutput = 'y';

      final List<String> output = await runCapturingPrint(
        commandRunner,
        <String>['publish', '--packages=foo', '--dry-run'],
      );

      expect(
        processRunner.recordedCalls.map((ProcessCall call) => call.executable),
        isNot(contains('git-push')),
      );
      expect(
        output,
        containsAllInOrder(<Matcher>[
          contains('=============== DRY RUN ==============='),
          contains('Running `pub publish ` in ${plugin.path}...'),
          contains('Tagging release foo-v0.0.1...'),
          contains('Pushing tag to upstream...'),
          contains('Published foo successfully!'),
        ]),
      );
    });

    test('to different remotes based on a flag', () async {
      createFakePlugin('foo', packagesDir, examples: <String>[]);

      mockStdin.readLineOutput = 'y';

      final List<String> output = await runCapturingPrint(
        commandRunner,
        <String>['publish', '--packages=foo', '--remote', 'origin'],
      );

      expect(
        processRunner.recordedCalls,
        contains(
          const ProcessCall('git-push', <String>['origin', 'foo-v0.0.1'], null),
        ),
      );
      expect(
        output,
        containsAllInOrder(<Matcher>[contains('Published foo successfully!')]),
      );
    });
  });

  group('--already-tagged', () {
    test('passes when HEAD has the expected tag', () async {
      createFakePlugin('foo', packagesDir, examples: <String>[]);

      processRunner.mockProcessesForExecutable['git-tag'] = <FakeProcessInfo>[
        FakeProcessInfo(MockProcess()), // Skip the initializeRun call.
        FakeProcessInfo(MockProcess(stdout: 'foo-v0.0.1\n'), <String>[
          '--points-at',
          'HEAD',
        ]),
      ];

      await runCapturingPrint(commandRunner, <String>[
        'publish',
        '--packages=foo',
        '--already-tagged',
      ]);
    });

    test('fails if HEAD does not have the expected tag', () async {
      createFakePlugin('foo', packagesDir, examples: <String>[]);

      Error? commandError;
      final List<String> output = await runCapturingPrint(
        commandRunner,
        <String>['publish', '--packages=foo', '--already-tagged'],
        errorHandler: (Error e) {
          commandError = e;
        },
      );

      expect(commandError, isA<ToolExit>());
      expect(
        output,
        containsAllInOrder(<Matcher>[
          contains('The current checkout is not already tagged "foo-v0.0.1"'),
          contains('missing tag'),
        ]),
      );
    });

    test('does not create or push tags', () async {
      createFakePlugin('foo', packagesDir, examples: <String>[]);

      processRunner.mockProcessesForExecutable['git-tag'] = <FakeProcessInfo>[
        FakeProcessInfo(MockProcess()), // Skip the initializeRun call.
        FakeProcessInfo(MockProcess(stdout: 'foo-v0.0.1\n'), <String>[
          '--points-at',
          'HEAD',
        ]),
      ];

      await runCapturingPrint(commandRunner, <String>[
        'publish',
        '--packages=foo',
        '--already-tagged',
      ]);

      expect(
        processRunner.recordedCalls,
        isNot(
          contains(const ProcessCall('git-tag', <String>['foo-v0.0.1'], null)),
        ),
      );
      expect(
        processRunner.recordedCalls.map((ProcessCall call) => call.executable),
        isNot(contains('git-push')),
      );
    });
  });

  group('Auto release (all-changed flag)', () {
    test('can release newly created plugins', () async {
      mockHttpResponses['plugin1'] = <String, dynamic>{
        'name': 'plugin1',
        'versions': <String>[],
      };

      mockHttpResponses['plugin2'] = <String, dynamic>{
        'name': 'plugin2',
        'versions': <String>[],
      };

      // Non-federated
      final RepositoryPackage plugin1 = createFakePlugin(
        'plugin1',
        packagesDir,
      );
      // federated
      final RepositoryPackage plugin2 = createFakePlugin(
        'plugin2',
        packagesDir.childDirectory('plugin2'),
      );
      processRunner.mockProcessesForExecutable['git-diff'] = <FakeProcessInfo>[
        FakeProcessInfo(
          MockProcess(
            stdout:
                '${plugin1.pubspecFile.path}\n'
                '${plugin2.pubspecFile.path}\n',
          ),
        ),
      ];
      mockStdin.readLineOutput = 'y';

      final List<String> output = await runCapturingPrint(
        commandRunner,
        <String>['publish', '--all-changed', '--base-sha=HEAD~'],
      );

      expect(
        output,
        containsAllInOrder(<Matcher>[
          contains(
            'Publishing all packages that have changed relative to "HEAD~"',
          ),
          contains('Running `pub publish ` in ${plugin1.path}...'),
          contains('Running `pub publish ` in ${plugin2.path}...'),
          contains('plugin1 - published'),
          contains('plugin2/plugin2 - published'),
        ]),
      );
      expect(
        processRunner.recordedCalls,
        contains(
          const ProcessCall('git-push', <String>[
            'upstream',
            'plugin1-v0.0.1',
          ], null),
        ),
      );
      expect(
        processRunner.recordedCalls,
        contains(
          const ProcessCall('git-push', <String>[
            'upstream',
            'plugin2-v0.0.1',
          ], null),
        ),
      );
    });

    test(
      'can release newly created plugins, while there are existing plugins',
      () async {
        mockHttpResponses['plugin0'] = <String, dynamic>{
          'name': 'plugin0',
          'versions': <String>['0.0.1'],
        };

        mockHttpResponses['plugin1'] = <String, dynamic>{
          'name': 'plugin1',
          'versions': <String>[],
        };

        mockHttpResponses['plugin2'] = <String, dynamic>{
          'name': 'plugin2',
          'versions': <String>[],
        };

        // The existing plugin.
        createFakePlugin('plugin0', packagesDir);
        // Non-federated
        final RepositoryPackage plugin1 = createFakePlugin(
          'plugin1',
          packagesDir,
        );
        // federated
        final RepositoryPackage plugin2 = createFakePlugin(
          'plugin2',
          packagesDir.childDirectory('plugin2'),
        );

        // Git results for plugin0 having been released already, and plugin1 and
        // plugin2 being new.
        processRunner.mockProcessesForExecutable['git-tag'] = <FakeProcessInfo>[
          FakeProcessInfo(MockProcess(stdout: 'plugin0-v0.0.1\n')),
        ];
        processRunner.mockProcessesForExecutable['git-diff'] =
            <FakeProcessInfo>[
              FakeProcessInfo(
                MockProcess(
                  stdout:
                      '${plugin1.pubspecFile.path}\n'
                      '${plugin2.pubspecFile.path}\n',
                ),
              ),
            ];

        mockStdin.readLineOutput = 'y';

        final List<String> output = await runCapturingPrint(
          commandRunner,
          <String>['publish', '--all-changed', '--base-sha=HEAD~'],
        );

        expect(
          output,
          containsAllInOrder(<String>[
            'Running `pub publish ` in ${plugin1.path}...\n',
            'Running `pub publish ` in ${plugin2.path}...\n',
          ]),
        );
        expect(
          processRunner.recordedCalls,
          contains(
            const ProcessCall('git-push', <String>[
              'upstream',
              'plugin1-v0.0.1',
            ], null),
          ),
        );
        expect(
          processRunner.recordedCalls,
          contains(
            const ProcessCall('git-push', <String>[
              'upstream',
              'plugin2-v0.0.1',
            ], null),
          ),
        );
      },
    );

    test('can release newly created plugins, dry run', () async {
      mockHttpResponses['plugin1'] = <String, dynamic>{
        'name': 'plugin1',
        'versions': <String>[],
      };

      mockHttpResponses['plugin2'] = <String, dynamic>{
        'name': 'plugin2',
        'versions': <String>[],
      };

      // Non-federated
      final RepositoryPackage plugin1 = createFakePlugin(
        'plugin1',
        packagesDir,
      );
      // federated
      final RepositoryPackage plugin2 = createFakePlugin(
        'plugin2',
        packagesDir.childDirectory('plugin2'),
      );

      processRunner.mockProcessesForExecutable['git-diff'] = <FakeProcessInfo>[
        FakeProcessInfo(
          MockProcess(
            stdout:
                '${plugin1.pubspecFile.path}\n'
                '${plugin2.pubspecFile.path}\n',
          ),
        ),
      ];
      mockStdin.readLineOutput = 'y';

      final List<String> output = await runCapturingPrint(
        commandRunner,
        <String>['publish', '--all-changed', '--base-sha=HEAD~', '--dry-run'],
      );

      expect(
        output,
        containsAllInOrder(<Matcher>[
          contains('=============== DRY RUN ==============='),
          contains('Running `pub publish ` in ${plugin1.path}...'),
          contains('Tagging release plugin1-v0.0.1...'),
          contains('Pushing tag to upstream...'),
          contains('Published plugin1 successfully!'),
          contains('Running `pub publish ` in ${plugin2.path}...'),
          contains('Tagging release plugin2-v0.0.1...'),
          contains('Pushing tag to upstream...'),
          contains('Published plugin2 successfully!'),
        ]),
      );
      expect(
        processRunner.recordedCalls.map((ProcessCall call) => call.executable),
        isNot(contains('git-push')),
      );
    });

    test('version change triggers releases.', () async {
      mockHttpResponses['plugin1'] = <String, dynamic>{
        'name': 'plugin1',
        'versions': <String>['0.0.1'],
      };

      mockHttpResponses['plugin2'] = <String, dynamic>{
        'name': 'plugin2',
        'versions': <String>['0.0.1'],
      };

      // Non-federated
      final RepositoryPackage plugin1 = createFakePlugin(
        'plugin1',
        packagesDir,
        version: '0.0.2',
      );
      // federated
      final RepositoryPackage plugin2 = createFakePlugin(
        'plugin2',
        packagesDir.childDirectory('plugin2'),
        version: '0.0.2',
      );

      processRunner.mockProcessesForExecutable['git-diff'] = <FakeProcessInfo>[
        FakeProcessInfo(
          MockProcess(
            stdout:
                '${plugin1.pubspecFile.path}\n'
                '${plugin2.pubspecFile.path}\n',
          ),
        ),
      ];

      mockStdin.readLineOutput = 'y';

      final List<String> output2 = await runCapturingPrint(
        commandRunner,
        <String>['publish', '--all-changed', '--base-sha=HEAD~'],
      );
      expect(
        output2,
        containsAllInOrder(<Matcher>[
          contains('Running `pub publish ` in ${plugin1.path}...'),
          contains('Published plugin1 successfully!'),
          contains('Running `pub publish ` in ${plugin2.path}...'),
          contains('Published plugin2 successfully!'),
        ]),
      );
      expect(
        processRunner.recordedCalls,
        contains(
          const ProcessCall('git-push', <String>[
            'upstream',
            'plugin1-v0.0.2',
          ], null),
        ),
      );
      expect(
        processRunner.recordedCalls,
        contains(
          const ProcessCall('git-push', <String>[
            'upstream',
            'plugin2-v0.0.2',
          ], null),
        ),
      );
    });

    test(
      'delete package will not trigger publish but exit the command successfully!',
      () async {
        mockHttpResponses['plugin1'] = <String, dynamic>{
          'name': 'plugin1',
          'versions': <String>['0.0.1'],
        };

        mockHttpResponses['plugin2'] = <String, dynamic>{
          'name': 'plugin2',
          'versions': <String>['0.0.1'],
        };

        // Non-federated
        final RepositoryPackage plugin1 = createFakePlugin(
          'plugin1',
          packagesDir,
          version: '0.0.2',
        );
        // federated
        final RepositoryPackage plugin2 = createFakePlugin(
          'plugin2',
          packagesDir.childDirectory('plugin2'),
        );
        plugin2.directory.deleteSync(recursive: true);

        processRunner.mockProcessesForExecutable['git-diff'] =
            <FakeProcessInfo>[
              FakeProcessInfo(
                MockProcess(
                  stdout:
                      '${plugin1.pubspecFile.path}\n'
                      '${plugin2.pubspecFile.path}\n',
                ),
              ),
            ];

        mockStdin.readLineOutput = 'y';

        final List<String> output2 = await runCapturingPrint(
          commandRunner,
          <String>['publish', '--all-changed', '--base-sha=HEAD~'],
        );
        expect(
          output2,
          containsAllInOrder(<Matcher>[
            contains('Running `pub publish ` in ${plugin1.path}...'),
            contains('Published plugin1 successfully!'),
            contains(
              'The pubspec file for plugin2/plugin2 does not exist, so no publishing will happen.\nSafe to ignore if the package is deleted in this commit.\n',
            ),
            contains('SKIPPING: package deleted'),
            contains('skipped (with warning)'),
          ]),
        );
        expect(
          processRunner.recordedCalls,
          contains(
            const ProcessCall('git-push', <String>[
              'upstream',
              'plugin1-v0.0.2',
            ], null),
          ),
        );
      },
    );

    test(
      'Existing versions do not trigger release, also prints out message.',
      () async {
        mockHttpResponses['plugin1'] = <String, dynamic>{
          'name': 'plugin1',
          'versions': <String>['0.0.2'],
        };

        mockHttpResponses['plugin2'] = <String, dynamic>{
          'name': 'plugin2',
          'versions': <String>['0.0.2'],
        };

        // Non-federated
        final RepositoryPackage plugin1 = createFakePlugin(
          'plugin1',
          packagesDir,
          version: '0.0.2',
        );
        // federated
        final RepositoryPackage plugin2 = createFakePlugin(
          'plugin2',
          packagesDir.childDirectory('plugin2'),
          version: '0.0.2',
        );

        processRunner.mockProcessesForExecutable['git-diff'] =
            <FakeProcessInfo>[
              FakeProcessInfo(
                MockProcess(
                  stdout:
                      '${plugin1.pubspecFile.path}\n'
                      '${plugin2.pubspecFile.path}\n',
                ),
              ),
            ];
        processRunner.mockProcessesForExecutable['git-tag'] = <FakeProcessInfo>[
          FakeProcessInfo(
            MockProcess(
              stdout:
                  'plugin1-v0.0.2\n'
                  'plugin2-v0.0.2\n',
            ),
          ),
        ];

        final List<String> output = await runCapturingPrint(
          commandRunner,
          <String>['publish', '--all-changed', '--base-sha=HEAD~'],
        );

        expect(
          output,
          containsAllInOrder(<Matcher>[
            contains('plugin1 0.0.2 has already been published'),
            contains('SKIPPING: already published'),
            contains('plugin2 0.0.2 has already been published'),
            contains('SKIPPING: already published'),
          ]),
        );

        expect(
          processRunner.recordedCalls.map(
            (ProcessCall call) => call.executable,
          ),
          isNot(contains('git-push')),
        );
      },
    );

    test(
      'Existing versions do not trigger release, but fail if the tags do not exist.',
      () async {
        mockHttpResponses['plugin1'] = <String, dynamic>{
          'name': 'plugin1',
          'versions': <String>['0.0.2'],
        };

        mockHttpResponses['plugin2'] = <String, dynamic>{
          'name': 'plugin2',
          'versions': <String>['0.0.2'],
        };

        // Non-federated
        final RepositoryPackage plugin1 = createFakePlugin(
          'plugin1',
          packagesDir,
          version: '0.0.2',
        );
        // federated
        final RepositoryPackage plugin2 = createFakePlugin(
          'plugin2',
          packagesDir.childDirectory('plugin2'),
          version: '0.0.2',
        );

        processRunner.mockProcessesForExecutable['git-diff'] =
            <FakeProcessInfo>[
              FakeProcessInfo(
                MockProcess(
                  stdout:
                      '${plugin1.pubspecFile.path}\n'
                      '${plugin2.pubspecFile.path}\n',
                ),
              ),
            ];

        Error? commandError;
        final List<String> output = await runCapturingPrint(
          commandRunner,
          <String>['publish', '--all-changed', '--base-sha=HEAD~'],
          errorHandler: (Error e) {
            commandError = e;
          },
        );

        expect(commandError, isA<ToolExit>());
        expect(
          output,
          containsAllInOrder(<Matcher>[
            contains(
              'plugin1 0.0.2 has already been published, '
              'however the git release tag (plugin1-v0.0.2) was not found.',
            ),
            contains(
              'plugin2 0.0.2 has already been published, '
              'however the git release tag (plugin2-v0.0.2) was not found.',
            ),
          ]),
        );
        expect(
          processRunner.recordedCalls.map(
            (ProcessCall call) => call.executable,
          ),
          isNot(contains('git-push')),
        );
      },
    );

    test('No version change does not release any plugins', () async {
      // Non-federated
      final RepositoryPackage plugin1 = createFakePlugin(
        'plugin1',
        packagesDir,
      );
      // federated
      final RepositoryPackage plugin2 = createFakePlugin(
        'plugin2',
        packagesDir.childDirectory('plugin2'),
      );

      processRunner.mockProcessesForExecutable['git-diff'] = <FakeProcessInfo>[
        FakeProcessInfo(
          MockProcess(
            stdout:
                '${plugin1.libDirectory.childFile('plugin1.dart').path}\n'
                '${plugin2.libDirectory.childFile('plugin2.dart').path}\n',
          ),
        ),
      ];

      final List<String> output = await runCapturingPrint(
        commandRunner,
        <String>['publish', '--all-changed', '--base-sha=HEAD~'],
      );

      expect(output, containsAllInOrder(<String>['Ran for 0 package(s)']));
      expect(
        processRunner.recordedCalls.map((ProcessCall call) => call.executable),
        isNot(contains('git-push')),
      );
    });

    test('Do not release flutter_plugin_tools', () async {
      mockHttpResponses['plugin1'] = <String, dynamic>{
        'name': 'flutter_plugin_tools',
        'versions': <String>[],
      };

      final RepositoryPackage flutterPluginTools = createFakePlugin(
        'flutter_plugin_tools',
        packagesDir,
      );
      processRunner.mockProcessesForExecutable['git-diff'] = <FakeProcessInfo>[
        FakeProcessInfo(
          MockProcess(stdout: flutterPluginTools.pubspecFile.path),
        ),
      ];

      final List<String> output = await runCapturingPrint(
        commandRunner,
        <String>['publish', '--all-changed', '--base-sha=HEAD~'],
      );

      expect(
        output,
        containsAllInOrder(<Matcher>[
          contains(
            'SKIPPING: publishing flutter_plugin_tools via the tool is not supported',
          ),
        ]),
      );
      expect(
        output.contains(
          'Running `pub publish ` in ${flutterPluginTools.path}...',
        ),
        isFalse,
      );
      expect(
        processRunner.recordedCalls.map((ProcessCall call) => call.executable),
        isNot(contains('git-push')),
      );
    });
  });

  group('credential location', () {
    test('Linux with XDG', () async {
      platform = MockPlatform(isLinux: true);
      platform.environment['XDG_CONFIG_HOME'] = '/xdghome/config';
      command = PublishCommand(packagesDir, platform: platform);

      expect(
        command.credentialsPath,
        '/xdghome/config/dart/pub-credentials.json',
      );
    });

    test('Linux without XDG', () async {
      platform = MockPlatform(isLinux: true);
      platform.environment['HOME'] = '/home';
      command = PublishCommand(packagesDir, platform: platform);

      expect(
        command.credentialsPath,
        '/home/.config/dart/pub-credentials.json',
      );
    });

    test('macOS', () async {
      platform = MockPlatform(isMacOS: true);
      platform.environment['HOME'] = '/Users/someuser';
      command = PublishCommand(packagesDir, platform: platform);

      expect(
        command.credentialsPath,
        '/Users/someuser/Library/Application Support/dart/pub-credentials.json',
      );
    });

    test('Windows', () async {
      platform = MockPlatform(isWindows: true);
      platform.environment['APPDATA'] = r'C:\Users\SomeUser\AppData';
      command = PublishCommand(packagesDir, platform: platform);

      expect(
        command.credentialsPath,
        r'C:\Users\SomeUser\AppData\dart\pub-credentials.json',
      );
    });
  });
}

/// An extension of [RecordingProcessRunner] that stores 'flutter pub publish'
/// calls so that their input streams can be checked in tests.
class TestProcessRunner extends RecordingProcessRunner {
  // Most recent returned publish process.
  late MockProcess mockPublishProcess;

  @override
  Future<io.Process> start(
    String executable,
    List<String> args, {
    Directory? workingDirectory,
  }) async {
    final io.Process process = await super.start(
      executable,
      args,
      workingDirectory: workingDirectory,
    );
    if (executable == 'flutter' &&
        args.isNotEmpty &&
        args[0] == 'pub' &&
        args[1] == 'publish') {
      mockPublishProcess = process as MockProcess;
    }
    return process;
  }
}

class MockStdin extends Mock implements io.Stdin {
  List<List<int>> mockUserInputs = <List<int>>[];
  final StreamController<List<int>> _controller = StreamController<List<int>>();
  String? readLineOutput;

  @override
  Stream<S> transform<S>(StreamTransformer<List<int>, S> streamTransformer) {
    mockUserInputs.forEach(_addUserInputsToSteam);
    return _controller.stream.transform(streamTransformer);
  }

  @override
  StreamSubscription<List<int>> listen(
    void Function(List<int> event)? onData, {
    Function? onError,
    void Function()? onDone,
    bool? cancelOnError,
  }) {
    return _controller.stream.listen(
      onData,
      onError: onError,
      onDone: onDone,
      cancelOnError: cancelOnError,
    );
  }

  @override
  String? readLineSync({
    Encoding encoding = io.systemEncoding,
    bool retainNewlines = false,
  }) => readLineOutput;

  void _addUserInputsToSteam(List<int> input) => _controller.add(input);
}
